File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/rating-comments.php
<?php
/**
* Rail ratings -> real WordPress comments.
*
* One PUBLIC route the float rail's rating bubble posts to. It inserts a normal WP comment
* through wp_new_comment() - so the site's own moderation settings, flood control, dedupe
* and notification emails all apply exactly as if the reader had used the comment form -
* and attaches WPRM's comment-rating meta so an approved comment counts toward the recipe
* card's stars.
*
* Product rules (approved mock, 2026-08-25):
* - Comments ride only 4-5 star ratings; the client never offers the comment step below
* that, and this endpoint refuses it too (defense in depth, not the primary gate).
* - comment text, author name and email are all required (the standard WP comment
* fields). The email is stored on the comment as always, never displayed by us.
* - Nothing auto-publishes: whatever the site's moderation settings decide, decides.
*
* Abuse posture for a public route: a honeypot field (bots that fill "website" get a
* pretend success and no comment), a per-IP transient rate limit, hard length caps, and
* comments_open() honored - a post with closed comments takes nothing.
*/
if (!defined('ABSPATH')) { exit; }
const ALLSPICE_RATING_COMMENT_MAX_LEN = 3000;
const ALLSPICE_RATING_RATE_LIMIT = 6; // submissions per IP per window
const ALLSPICE_RATING_RATE_WINDOW = 3600; // seconds
add_action('rest_api_init', 'allspice_rating_register_routes');
function allspice_rating_register_routes(): void {
register_rest_route('allspice/v1', '/rating-comment', [
'methods' => 'POST',
'callback' => 'allspice_rating_comment_submit',
'permission_callback' => '__return_true',
'args' => [
'rating' => ['type' => 'integer', 'required' => true],
'comment' => ['type' => 'string', 'required' => true],
'author' => ['type' => 'string', 'required' => true],
'email' => ['type' => 'string', 'required' => true],
'post_url' => ['type' => 'string', 'required' => true],
'website' => ['type' => 'string', 'required' => false],
],
]);
}
function allspice_rating_comment_submit($request) {
// Honeypot: real readers never see this field. Answer success so bots learn nothing.
if (trim((string)$request->get_param('website')) !== '') {
return rest_ensure_response(['ok' => true]);
}
$ip = isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : '';
$bucket = 'allspice_rating_rl_' . md5($ip);
$count = (int)get_transient($bucket);
if ($count >= ALLSPICE_RATING_RATE_LIMIT) {
return new WP_Error('allspice_rating_limited', 'Too many submissions. Try again later.', ['status' => 429]);
}
set_transient($bucket, $count + 1, ALLSPICE_RATING_RATE_WINDOW);
$rating = (int)$request->get_param('rating');
if ($rating < 4 || $rating > 5) {
// The client only offers the comment step at 4-5 stars; anything else here is not ours.
return new WP_Error('allspice_rating_gate', 'Comments are only accepted with a 4 or 5 star rating.', ['status' => 422]);
}
$comment = trim(wp_strip_all_tags((string)$request->get_param('comment')));
if (mb_strlen($comment) < 3) {
return new WP_Error('allspice_rating_comment', 'Comment text is required.', ['status' => 422]);
}
if (mb_strlen($comment) > ALLSPICE_RATING_COMMENT_MAX_LEN) {
$comment = mb_substr($comment, 0, ALLSPICE_RATING_COMMENT_MAX_LEN);
}
$author = sanitize_text_field((string)$request->get_param('author'));
if ($author === '' || mb_strlen($author) > 100) {
return new WP_Error('allspice_rating_author', 'A name is required.', ['status' => 422]);
}
$email = sanitize_email((string)$request->get_param('email'));
if (!is_email($email)) {
return new WP_Error('allspice_rating_email', 'A valid email is required.', ['status' => 422]);
}
$post_url = esc_url_raw((string)$request->get_param('post_url'));
$post_id = $post_url ? url_to_postid($post_url) : 0;
if (!$post_id) {
return new WP_Error('allspice_rating_post', 'No post found for that URL.', ['status' => 404]);
}
if (!comments_open($post_id)) {
return new WP_Error('allspice_rating_closed', 'Comments are closed on this post.', ['status' => 409]);
}
$commentdata = [
'comment_post_ID' => $post_id,
'comment_author' => $author,
'comment_author_email' => $email,
'comment_author_url' => '',
'comment_content' => $comment,
'comment_type' => 'comment',
'comment_parent' => 0,
'user_id' => 0,
];
// $wp_error = true: a refusal (dedupe, flood, spam plugin) comes back as WP_Error
// instead of dying, and the reader gets an honest failure in the bubble.
$comment_id = wp_new_comment($commentdata, true);
if (is_wp_error($comment_id)) {
$status = $comment_id->get_error_code() === 'comment_duplicate' ? 409 : 400;
return new WP_Error('allspice_rating_refused', $comment_id->get_error_message(), ['status' => $status]);
}
// WPRM reads this meta for comment ratings; an approved comment then counts toward the
// recipe card's aggregate stars. Harmless on sites without WPRM. The source marker lets
// publishers (and our sync) tell rail-collected comments apart.
update_comment_meta((int)$comment_id, 'wprm-comment-rating', $rating);
update_comment_meta((int)$comment_id, 'allspice_source', 'rail_rating');
return rest_ensure_response(['ok' => true, 'commentId' => (int)$comment_id]);
}